// Copyright (c) 2021, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

/// Implements support for dependency-bot style automated upgrades.
/// It is still work in progress - do not rely on the current output.
library;

import 'dart:convert';
import 'dart:io';

import 'package:collection/collection.dart';
import 'package:path/path.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:yaml/yaml.dart';
import 'package:yaml_edit/yaml_edit.dart';

import '../command.dart';
import '../entrypoint.dart';
import '../exceptions.dart';
import '../io.dart';
import '../lock_file.dart';
import '../log.dart' as log;
import '../package.dart';
import '../package_name.dart';
import '../pubspec.dart';
import '../pubspec_utils.dart';
import '../sdk.dart';
import '../solver.dart';
import '../solver/version_solver.dart';
import '../source/git.dart';
import '../source/hosted.dart';
import '../source/root.dart';
import '../system_cache.dart';
import '../utils.dart';

class DependencyServicesReportCommand extends PubCommand {
  @override
  String get name => 'report';
  @override
  String get description =>
      'Output a machine-digestible '
      'report of the upgrade options for each dependency.';
  @override
  String get argumentsDescription => '[options]';

  @override
  bool get takesArguments => false;

  DependencyServicesReportCommand() {
    argParser.addOption(
      'directory',
      abbr: 'C',
      help: 'Run this in the directory <dir>.',
      valueHelp: 'dir',
    );
  }

  @override
  Future<void> runProtected() async {
    _checkAtRoot(entrypoint);
    final stdinString = await utf8.decodeStream(stdin);
    final input =
        json.decode(stdinString.isEmpty ? '{}' : stdinString)
            as Map<String, Object?>;
    final additionalConstraints = _parseDisallowed(input, cache);
    final targetPackageName = input['target'];
    if (targetPackageName is! String?) {
      throw const FormatException('"target" should be a String.');
    }

    final compatibleWorkspace = entrypoint.workspaceRoot.transformWorkspace(
      (p) => stripDependencyOverrides(p.pubspec),
    );

    final breakingWorkspace = compatibleWorkspace.transformWorkspace(
      (p) => stripVersionBounds(p.pubspec),
    );

    final compatiblePackagesResult = await _tryResolve(
      compatibleWorkspace,
      cache,
      additionalConstraints: additionalConstraints,
    );

    final breakingPackagesResult = await _tryResolve(
      breakingWorkspace,
      cache,
      additionalConstraints: additionalConstraints,
    );

    final currentPackages = await _computeCurrentPackages(entrypoint, cache);

    final dependencies = <Object>[];
    final result = <String, Object>{'dependencies': dependencies};

    final targetPackage =
        targetPackageName == null ? null : currentPackages[targetPackageName];

    for (final package
        in targetPackage == null
            ? currentPackages.values
            : <PackageId>[targetPackage]) {
      final compatibleVersion = compatiblePackagesResult?.firstWhereOrNull(
        (element) => element.name == package.name,
      );
      final multiBreakingVersion = breakingPackagesResult?.firstWhereOrNull(
        (element) => element.name == package.name,
      );

      final kind = _kindString(compatibleWorkspace, package.name);
      PackageId? singleBreakingVersion;

      if (kind != 'transitive') {
        final singleBreakingWorkspace = compatibleWorkspace.transformWorkspace((
          p,
        ) {
          final r = stripVersionBounds(p.pubspec, stripOnly: [package.name]);
          return r;
        });
        final singleBreakingPackagesResult = await _tryResolve(
          singleBreakingWorkspace,
          cache,
        );
        singleBreakingVersion = singleBreakingPackagesResult?.firstWhereOrNull(
          (element) => element.name == package.name,
        );
      }
      PackageId? smallestUpgrade;
      if (additionalConstraints.any(
        (c) => c.range.toRef() == package.toRef() && !c.range.allows(package),
      )) {
        // Current version disallowed by restrictions.
        final atLeastCurrentWorkspace = compatibleWorkspace.transformWorkspace(
          (p) => atLeastCurrent(
            p.pubspec,
            entrypoint.lockFile.packages.values.toList(),
          ),
        );

        final smallestUpgradeResult = await _tryResolve(
          atLeastCurrentWorkspace,
          cache,
          solveType: SolveType.downgrade,
          additionalConstraints: additionalConstraints,
        );

        smallestUpgrade = smallestUpgradeResult?.firstWhereOrNull(
          (element) => element.name == package.name,
        );
      }

      Future<List<Object>> computeUpgradeSet(
        PackageId? package,
        _UpgradeType upgradeType,
      ) async {
        return await _computeUpgradeSet(
          compatibleWorkspace,
          package,
          entrypoint,
          cache,
          currentPackages: currentPackages,
          upgradeType: upgradeType,
          additionalConstraints: additionalConstraints,
        );
      }

      dependencies.add({
        'name': package.name,
        'version': package.versionOrHash(),
        'kind': kind,
        'source': _source(package, containingDir: directory),
        'latest':
            (await cache.getLatest(
              package.toRef(),
              version: package.version,
            ))?.versionOrHash(),
        'constraint':
            _constraintIntersection(
              compatibleWorkspace,
              package.name,
            )?.toString(),
        'compatible': await computeUpgradeSet(
          compatibleVersion,
          _UpgradeType.compatible,
        ),
        'singleBreaking':
            kind != 'transitive' && singleBreakingVersion == null
                ? <Object>[]
                : await computeUpgradeSet(
                  singleBreakingVersion,
                  _UpgradeType.singleBreaking,
                ),
        'multiBreaking':
            kind != 'transitive' && multiBreakingVersion != null
                ? await computeUpgradeSet(
                  multiBreakingVersion,
                  _UpgradeType.multiBreaking,
                )
                : <Object>[],
        if (smallestUpgrade != null)
          'smallestUpdate': await computeUpgradeSet(
            smallestUpgrade,
            _UpgradeType.smallestUpdate,
          ),
      });
    }
    log.message(const JsonEncoder.withIndent('  ').convert(result));
  }
}

class DependencyServicesListCommand extends PubCommand {
  @override
  String get name => 'list';

  @override
  String get description =>
      'Output a machine digestible listing of all dependencies';

  @override
  bool get takesArguments => false;

  DependencyServicesListCommand() {
    argParser.addOption(
      'directory',
      abbr: 'C',
      help: 'Run this in the directory <dir>.',
      valueHelp: 'dir',
    );
  }

  @override
  Future<void> runProtected() async {
    _checkAtRoot(entrypoint);
    final currentPackages =
        fileExists(entrypoint.lockFilePath)
            ? entrypoint.lockFile.packages.values.toList()
            : (await _tryResolve(entrypoint.workspaceRoot, cache) ??
                <PackageId>[]);

    final dependencies = <Object>[];
    final result = <String, Object>{'dependencies': dependencies};

    for (final package in currentPackages.where((p) => !p.isRoot)) {
      dependencies.add({
        'name': package.name,
        'version': package.versionOrHash(),
        'kind': _kindString(entrypoint.workspaceRoot, package.name),
        'constraint':
            _constraintIntersection(
              entrypoint.workspaceRoot,
              package.name,
            )?.toString(),
        'source': _source(package, containingDir: directory),
      });
    }
    log.message(const JsonEncoder.withIndent('  ').convert(result));
  }
}

extension on PackageId {
  String versionOrHash() {
    final description = this.description;
    if (description is ResolvedGitDescription) {
      return description.resolvedRef;
    } else {
      return version.toString();
    }
  }
}

enum _UpgradeType {
  /// Only upgrade pubspec.lock.
  compatible,

  /// Unlock at most one dependency in pubspec.yaml.
  singleBreaking,

  /// Unlock any dependencies in pubspec.yaml needed for getting the
  /// latest resolvable version.
  multiBreaking,

  /// Try to upgrade as little as possible.
  smallestUpdate,
}

class DependencyServicesApplyCommand extends PubCommand {
  @override
  String get name => 'apply';

  @override
  String get description =>
      'Updates pubspec.yaml and pubspec.lock according to input.';

  @override
  bool get takesArguments => true;

  DependencyServicesApplyCommand() {
    argParser.addOption(
      'directory',
      abbr: 'C',
      help: 'Run this in the directory <dir>.',
      valueHelp: 'dir',
    );
  }

  @override
  Future<void> runProtected() async {
    final toApply = <_PackageVersion>[];
    final input = json.decode(await utf8.decodeStream(stdin));
    if (input is! Map<String, dynamic>) {
      fail('Bad input, must be json map');
    }
    final dependencyChanges = input['dependencyChanges'];
    if (dependencyChanges is! List) {
      fail('Bad input. `dependencyChanges` must be a list');
    }
    for (final change in dependencyChanges) {
      if (change is! Map<String, dynamic>) {
        fail('Bad input. Each element of `dependencyChanges` must be a map.');
      }
      toApply.add(
        _PackageVersion(
          change['name'] as String,
          change['version'] as String?,
          change['constraint'] != null
              ? VersionConstraint.parse(change['constraint'] as String)
              : null,
        ),
      );
    }
    final updatedPubspecs = <String, YamlEditor>{};
    _checkAtRoot(entrypoint);
    for (final package in entrypoint.workspaceRoot.transitiveWorkspace) {
      final pubspec = package.pubspec;
      final pubspecEditor = YamlEditor(readTextFile(package.pubspecPath));
      for (final p in toApply) {
        final targetConstraint = p.constraint;
        final targetPackage = p.name;
        final targetVersion = p.version;
        late final section =
            pubspec.dependencies[targetPackage] != null
                ? 'dependencies'
                : pubspec.devDependencies[targetPackage] != null
                ? 'dev_dependencies'
                : null;
        if (section != null) {
          if (targetConstraint != null) {
            final packageConfig =
                pubspecEditor.parseAt([section, targetPackage]).value;
            if (packageConfig == null || packageConfig is String) {
              pubspecEditor.update([
                section,
                targetPackage,
              ], targetConstraint.toString());
            } else if (packageConfig is Map) {
              pubspecEditor.update([
                section,
                targetPackage,
                'version',
              ], targetConstraint.toString());
            } else {
              fail(
                'The dependency $targetPackage does not have a '
                'map or string as a description',
              );
            }
          } else if (targetVersion != null) {
            final constraint = _constraintOf(pubspec, targetPackage);
            if (constraint != null && !constraint.allows(targetVersion)) {
              pubspecEditor.update([
                section,
                targetPackage,
              ], VersionConstraint.compatibleWith(targetVersion).toString());
            }
          }
        }
        updatedPubspecs[package.dir] = pubspecEditor;
      }
    }
    final lockFile =
        fileExists(entrypoint.lockFilePath)
            ? readTextFile(entrypoint.lockFilePath)
            : null;
    final lockFileYaml = lockFile == null ? null : loadYaml(lockFile);

    final lockFileEditor = lockFile == null ? null : YamlEditor(lockFile);
    final hasContentHashes = _lockFileHasContentHashes(lockFileYaml);
    final usesPubDev = _lockFileUsesPubDev(lockFileYaml);
    for (final p in toApply) {
      final targetPackage = p.name;
      final targetVersion = p.version;
      final targetRevision = p.gitRevision;

      if (lockFileEditor != null) {
        if (lockFileYaml is! Map) {
          fail('Malformed pubspec.lock. Must be a map');
        }
        if (targetVersion != null &&
            (lockFileYaml['packages'] as Map).containsKey(targetPackage)) {
          lockFileEditor.update([
            'packages',
            targetPackage,
            'version',
          ], targetVersion.toString());
          // Remove the now outdated content-hash - it will be restored below
          // after resolution.
          final packageMap =
              lockFileEditor.parseAt([
                    'packages',
                    targetPackage,
                    'description',
                  ]).value
                  as Map;
          final hasSha = packageMap.containsKey('sha256');
          if (hasSha) {
            lockFileEditor.remove([
              'packages',
              targetPackage,
              'description',
              'sha256',
            ]);
          }
        } else if (targetRevision != null &&
            (lockFileYaml['packages'] as Map).containsKey(targetPackage)) {
          final ref = entrypoint.lockFile.packages[targetPackage]!.toRef();

          final currentDescription = ref.description as GitDescription;
          final updatedRef = PackageRef(
            targetPackage,
            GitDescription(
              url: currentDescription.url,
              path: currentDescription.path,
              ref: targetRevision,
              containingDir: directory,
              tagPattern: currentDescription.tagPattern,
            ),
          );
          final versions = await cache.getVersions(updatedRef);
          if (versions.isEmpty) {
            dataError(
              'Found no versions of $targetPackage '
              'with git revision `$targetRevision`.',
            );
          }
          // GitSource can only return a single version.
          assert(versions.length == 1);

          lockFileEditor.update([
            'packages',
            targetPackage,
            'version',
          ], versions.single.version.toString());
          lockFileEditor.update([
            'packages',
            targetPackage,
            'description',
            'resolved-ref',
          ], targetRevision);
        } else if (targetVersion == null &&
            targetRevision == null &&
            !(lockFileYaml['packages'] as Map).containsKey(targetPackage)) {
          dataError(
            'Trying to remove non-existing '
            'transitive dependency $targetPackage.',
          );
        }
      }
    }

    final updatedLockfile =
        lockFileEditor == null
            ? null
            : LockFile.parse(
              lockFileEditor.toString(),
              cache.sources,
              filePath: entrypoint.lockFilePath,
            );
    await log.errorsOnlyUnlessTerminal(() async {
      final updatedWorkspace = entrypoint.workspaceRoot.transformWorkspace(
        (package) => Pubspec.parse(
          updatedPubspecs[package.dir].toString(),
          cache.sources,
          location: toUri(package.pubspecPath),
          containingDescription: ResolvedRootDescription(
            RootDescription(package.dir),
          ),
        ),
      );
      // Resolve versions, this will update transitive dependencies that were
      // not passed in the input. And also counts as a validation of the input
      // by ensuring the resolution is valid.
      //
      // We don't use `acquireDependencies` as that downloads all the archives
      // to cache.
      // TODO: Handle HTTP exceptions gracefully!
      final solveResult = await resolveVersions(
        SolveType.get,
        cache,
        updatedWorkspace,
        lockFile: updatedLockfile,
      );
      for (final package in entrypoint.workspaceRoot.transitiveWorkspace) {
        final updatedPubspec = updatedPubspecs[package.dir]!;
        if (updatedPubspec.edits.isNotEmpty) {
          writeTextFile(package.pubspecPath, updatedPubspec.toString());
        }
      }
      // Only if we originally had a lock-file we write the resulting lockfile
      // back.
      if (updatedLockfile != null) {
        final updatedPackages = <PackageId>[];
        for (var package in solveResult.packages) {
          if (package.isRoot) continue;
          final description = package.description;
          // Handle content-hashes of hosted dependencies.
          if (description is ResolvedHostedDescription) {
            // Ensure we get content-hashes if the original lock-file had
            // them.
            if (hasContentHashes) {
              if (description.sha256 == null) {
                // We removed the hash above before resolution - as we get the
                // locked id back we need to find the content-hash from the
                // version listing.
                //
                // `pub get` gets this version-listing from the downloaded
                // archive but we don't want to download all archives - so we
                // copy it from the version listing.
                package = (await cache.getVersions(
                  package.toRef(),
                )).firstWhere((id) => id == package, orElse: () => package);
                if ((package.description as ResolvedHostedDescription).sha256 ==
                    null) {
                  // This happens when we resolved a package from a legacy
                  // server not providing archive_sha256. As a side-effect of
                  // downloading the package we compute and store the sha256.
                  package = (await cache.downloadPackage(package)).packageId;
                }
              }
            } else {
              // The original pubspec.lock did not have content-hashes. Remove
              // any content hash, so we don't start adding them.
              package = PackageId(
                package.name,
                package.version,
                description.withSha256(null),
              );
            }
            // Keep using https://pub.dartlang.org if the original lockfile
            // used it. This is to support lockfiles from old sdks.
            if (!usesPubDev &&
                HostedSource.isPubDevUrl(description.description.url)) {
              package = PackageId(
                package.name,
                package.version,
                ResolvedHostedDescription(
                  HostedDescription.raw(
                    package.name,
                    HostedSource.pubDartlangUrl,
                  ),
                  sha256:
                      (package.description as ResolvedHostedDescription).sha256,
                ),
              );
            }
          }
          updatedPackages.add(package);
        }

        final newLockFile = LockFile(
          updatedPackages,
          sdkConstraints: updatedLockfile.sdkConstraints,
          mainDependencies: entrypoint.lockFile.mainDependencies,
          devDependencies: entrypoint.lockFile.devDependencies,
          overriddenDependencies: entrypoint.lockFile.overriddenDependencies,
        );

        newLockFile.writeToFile(entrypoint.lockFilePath, cache);
      }
    });
    // Dummy message.
    log.message(json.encode({'dependencies': <Object>[]}));
  }
}

void _checkAtRoot(Entrypoint entrypoint) {
  if (entrypoint.workspaceRoot != entrypoint.workPackage) {
    fail('Only apply dependency_services to the root of the workspace.');
  }
}

class _PackageVersion {
  String name;
  Version? version;
  String? gitRevision;
  VersionConstraint? constraint;
  _PackageVersion(this.name, String? versionOrHash, this.constraint)
    : version = versionOrHash == null ? null : _tryParseVersion(versionOrHash),
      gitRevision = versionOrHash == null ? null : _tryParseHash(versionOrHash);
}

Version? _tryParseVersion(String v) {
  try {
    return Version.parse(v);
  } on FormatException {
    return null;
  }
}

String? _tryParseHash(String v) {
  if (RegExp(r'^[a-fA-F0-9]+$').hasMatch(v)) {
    return v;
  }
  return null;
}

Map<String, PackageRange>? _dependencySetOfPackage(
  Pubspec pubspec,
  PackageId package,
) {
  return pubspec.dependencies.containsKey(package.name)
      ? pubspec.dependencies
      : pubspec.devDependencies.containsKey(package.name)
      ? pubspec.devDependencies
      : null;
}

/// Return a constraint compatible with [newVersion].
///
/// By convention if the original constraint is pinned we return [newVersion].
/// Otherwise use [VersionConstraint.compatibleWith].
VersionConstraint _bumpConstraint(
  VersionConstraint original,
  Version newVersion,
) {
  if (original.isEmpty) return newVersion;
  if (original is VersionRange) {
    if (original.min == original.max) return newVersion;

    return VersionConstraint.compatibleWith(newVersion);
  }

  throw ArgumentError.value(
    original,
    'original',
    'Must be a Version range or empty',
  );
}

/// Return a constraint compatible with [newVersion], but including [original]
/// as well.
///
/// By convention if the original constraint is pinned, we don't widen the
/// constraint but return [newVersion] instead.
VersionConstraint _widenConstraint(
  VersionConstraint original,
  Version newVersion,
) {
  if (original.allows(newVersion)) return original;
  if (original is VersionRange) {
    final min = original.min;
    final max = original.max;
    if (min == max) return newVersion;
    if (max != null && newVersion >= max) {
      return _compatibleWithIfPossible(
        VersionRange(
          min: min,
          includeMin: original.includeMin,
          max: newVersion.nextBreaking.firstPreRelease,
        ),
      );
    }
    if (min != null && newVersion <= min) {
      return _compatibleWithIfPossible(
        VersionRange(
          min: newVersion,
          includeMin: true,
          max: max,
          includeMax: original.includeMax,
        ),
      );
    }
  }

  if (original.isEmpty) return newVersion;
  throw ArgumentError.value(
    original,
    'original',
    'Must be a Version range or empty',
  );
}

VersionConstraint _compatibleWithIfPossible(VersionRange versionRange) {
  final min = versionRange.min;
  if (min != null && min.nextBreaking.firstPreRelease == versionRange.max) {
    return VersionConstraint.compatibleWith(min);
  }
  return versionRange;
}

/// `true` iff any of the packages described by the [lockfile] has a
/// content-hash.
///
/// Undefined for invalid lock files, but mostly `true`.
bool _lockFileHasContentHashes(dynamic lockfile) {
  if (lockfile is! Map) return true;
  final packages = lockfile['packages'];
  if (packages is! Map) return true;

  /// We consider an empty lockfile ready to get content-hashes.
  if (packages.isEmpty) return true;
  for (final package in packages.values) {
    if (package is! Map) return true;
    final descriptor = package['description'];
    if (descriptor is! Map) return true;
    if (descriptor['sha256'] != null) return true;
  }
  return false;
}

/// Try to solve [package] return [PackageId]s in the resolution or `null` if no
/// resolution was found.
Future<List<PackageId>?> _tryResolve(
  Package package,
  SystemCache cache, {
  SolveType solveType = SolveType.upgrade,
  Iterable<ConstraintAndCause>? additionalConstraints,
}) async {
  final solveResult = await tryResolveVersions(
    solveType,
    cache,
    package,
    additionalConstraints: additionalConstraints,
  );

  return solveResult?.packages;
}

VersionConstraint? _constraintIntersection(
  Package workspace,
  String packageName,
) {
  final constraints =
      workspace.transitiveWorkspace
          .map((p) => _constraintOf(p.pubspec, packageName))
          .nonNulls;
  if (constraints.isEmpty) {
    return null;
  }
  return constraints
      .reduce((a, b) => a.intersect(b))
      .asCompatibleWithIfPossible();
}

VersionConstraint? _constraintOf(Pubspec pubspec, String packageName) {
  return (pubspec.dependencies[packageName] ??
          pubspec.devDependencies[packageName])
      ?.constraint;
}

String _kindString(Package workspace, String packageName) {
  return workspace.transitiveWorkspace.any(
        (p) => p.dependencies.containsKey(packageName),
      )
      ? 'direct'
      : workspace.transitiveWorkspace.any(
        (p) => p.devDependencies.containsKey(packageName),
      )
      ? 'dev'
      : 'transitive';
}

Map<String, Object?> _source(PackageId id, {required String containingDir}) {
  return {
    'type': id.source.name,
    'description': id.description.serializeForLockfile(
      containingDir: containingDir,
    ),
  };
}

/// The packages in the current lockfile or resolved from current pubspec.yaml.
/// Does not include the root package.
Future<Map<String, PackageId>> _computeCurrentPackages(
  Entrypoint entrypoint,
  SystemCache cache,
) async {
  late Map<String, PackageId> currentPackages;

  if (fileExists(entrypoint.lockFilePath)) {
    currentPackages = Map<String, PackageId>.from(entrypoint.lockFile.packages);
  } else {
    final resolution =
        await _tryResolve(entrypoint.workspaceRoot, cache) ??
        (throw DataException('Failed to resolve pubspec'));
    currentPackages = Map<String, PackageId>.fromIterable(
      resolution,
      key: (e) => (e as PackageId).name,
    );
  }
  for (final p in entrypoint.workspaceRoot.transitiveWorkspace) {
    currentPackages.remove(p.name);
  }
  return currentPackages;
}

Future<List<Object>> _computeUpgradeSet(
  Package workspace,
  PackageId? package,
  Entrypoint entrypoint,
  SystemCache cache, {
  required Map<String, PackageId> currentPackages,
  required _UpgradeType upgradeType,
  required List<ConstraintAndCause> additionalConstraints,
}) async {
  if (package == null) return [];
  final lockFile = entrypoint.lockFile;
  final upgradedWorkspace =
      (upgradeType == _UpgradeType.multiBreaking ||
              upgradeType == _UpgradeType.smallestUpdate)
          ? workspace.transformWorkspace((p) => stripVersionBounds(p.pubspec))
          : workspace.transformWorkspace((p) => p.pubspec.copyWith());

  for (final p in upgradedWorkspace.transitiveWorkspace) {
    final dependencySet = _dependencySetOfPackage(p.pubspec, package);
    if (dependencySet != null) {
      // Force the version to be the new version.
      dependencySet[package.name] = package.toRef().withConstraint(
        package.toRange().constraint,
      );
    }
  }

  final resolution = await tryResolveVersions(
    upgradeType == _UpgradeType.smallestUpdate
        ? SolveType.downgrade
        : SolveType.get,
    cache,
    upgradedWorkspace,
    lockFile: lockFile,
    additionalConstraints: additionalConstraints,
  );

  // TODO(sigurdm): improve error messages.
  if (resolution == null) {
    return [];
  }
  final workspaceNames = {...workspace.transitiveWorkspace.map((p) => p.name)};
  return [
    ...resolution.packages
        .where((r) {
          if (workspaceNames.contains(r.name)) return false;
          final originalVersion = currentPackages[r.name];
          return originalVersion == null || r != originalVersion;
        })
        .map((p) {
          final constraintIntersection = _constraintIntersection(
            workspace,
            p.name,
          );
          final currentPackage = currentPackages[p.name];
          return {
            'name': p.name,
            'version': p.versionOrHash(),
            'kind': _kindString(workspace, p.name),
            'source': _source(p, containingDir: entrypoint.workspaceRoot.dir),
            'constraintBumped':
                constraintIntersection == null
                    ? null
                    : upgradeType == _UpgradeType.compatible
                    ? constraintIntersection.toString()
                    : _bumpConstraint(
                      constraintIntersection,
                      p.version,
                    ).toString(),
            'constraintWidened':
                constraintIntersection == null
                    ? null
                    : upgradeType == _UpgradeType.compatible
                    ? constraintIntersection.toString()
                    : _widenConstraint(
                      constraintIntersection,
                      p.version,
                    ).toString(),
            'constraintBumpedIfNeeded':
                constraintIntersection == null
                    ? null
                    : upgradeType == _UpgradeType.compatible
                    ? constraintIntersection.toString()
                    : constraintIntersection.allows(p.version)
                    ? constraintIntersection.toString()
                    : _bumpConstraint(
                      constraintIntersection,
                      p.version,
                    ).toString(),
            'previousVersion': currentPackage?.versionOrHash(),
            'previousConstraint': constraintIntersection?.toString(),
            'previousSource':
                currentPackage == null
                    ? null
                    : _source(
                      currentPackage,
                      containingDir: entrypoint.workspaceRoot.dir,
                    ),
          };
        }),
    // Find packages that were removed by the resolution
    for (final oldPackageName in lockFile.packages.keys)
      if (!resolution.packages.any(
        (newPackage) => newPackage.name == oldPackageName,
      ))
        {
          'name': oldPackageName,
          'version': null,
          'kind': 'transitive', // Only transitive constraints can be removed.
          'constraintBumped': null,
          'constraintWidened': null,
          'constraintBumpedIfNeeded': null,
          'previousVersion': currentPackages[oldPackageName]?.versionOrHash(),
          'previousConstraint': null,
          'previous': _source(
            currentPackages[oldPackageName]!,
            containingDir: entrypoint.workspaceRoot.dir,
          ),
        },
  ];
}

List<ConstraintAndCause> _parseDisallowed(
  Map<String, Object?> input,
  SystemCache cache,
) {
  final disallowedList = input['disallowed'];
  if (disallowedList == null) {
    return [];
  }
  if (disallowedList is! List<Object?>) {
    throw const FormatException('Disallowed should be a list of maps');
  }
  final result = <ConstraintAndCause>[];
  for (final disallowed in disallowedList) {
    if (disallowed is! Map) {
      throw const FormatException('Disallowed should be a list of maps');
    }
    final name = disallowed['name'];
    if (name is! String) {
      throw const FormatException('"name" should be a string.');
    }
    final url = disallowed['url'] ?? cache.hosted.defaultUrl;
    if (url is! String) {
      throw const FormatException('"url" should be a string.');
    }
    final ref = PackageRef(name, HostedDescription(name, url));
    final constraints = disallowed['versions'];
    if (constraints is! List) {
      throw const FormatException('"versions" should be a list.');
    }
    final reason = disallowed['reason'];
    if (reason is! String?) {
      throw const FormatException('"reason", if present, should be a string.');
    }
    for (final entry in constraints) {
      if (entry is! Map) {
        throw const FormatException(
          'Each element of "versions" should be an object.',
        );
      }
      final rangeString = entry['range'];
      if (rangeString is! String) {
        throw const FormatException('"range" should be a string');
      }
      final range = VersionConstraint.parse(rangeString);
      result.add(
        ConstraintAndCause(
          PackageRange(ref, VersionConstraint.any.difference(range)),
          reason,
        ),
      );
    }
  }
  return result;
}

/// `true` iff any of the packages described by the [lockfile] uses
/// `https://pub.dev` as url.
///
/// Undefined for invalid lock files, but mostly `true`.
bool _lockFileUsesPubDev(dynamic lockfile) {
  if (lockfile is! Map) return true;
  final packages = lockfile['packages'];
  if (packages is! Map) return true;

  /// We consider an empty lockfile ready to get content-hashes.
  if (packages.isEmpty) return true;
  for (final package in packages.values) {
    if (package is! Map) return true;
    if (package['source'] != 'hosted') continue;
    final descriptor = package['description'];
    if (descriptor is! Map) return true;
    final url = descriptor['url'];
    if (url is! String) return true;
    if (HostedSource.isPubDevUrl(url) && url != HostedSource.pubDartlangUrl) {
      return true;
    }
  }
  return false;
}
